iT邦幫忙

2022 iThome 鐵人賽

DAY 26
0
Modern Web

【 我不會寫 React Component 】系列 第 26

如何製作輪播 carousel 4【 carousel | 我不會寫 React Component 】

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20221009/20107239Y7ahres4CE.jpg

本篇接續前篇 如何製作輪播 carousel 3【 carousel | 我不會寫 React Component 】
可以先看完上一篇再接續此篇。

Spec: Button for stop and start automatic rotation

須配有開啟/關閉自動輪播的按鈕。

describe("button for stop and start automatic rotation", () => {
  it(
    "the carousel also contains a rotation control button " +
      "that can stop and start automatic rotation",
    async () => {
      setup(100);
      expect(
        screen.getByText("Dynamic Europe: Amsterdam, Prague, Berlin")
          .parentElement
      ).toHaveAttribute("aria-live", "off");
      await user.click(screen.getByText("pause auto-rotation"));
      expect(
        screen.getByText("Dynamic Europe: Amsterdam, Prague, Berlin")
          .parentElement
      ).toHaveAttribute("aria-live", "polite");
      await user.click(screen.getByText("start auto-rotation"));
      await user.keyboard("{Shift>}{Tab}{/Shift}");
      await user.unhover(screen.getByText("start auto-rotation"));
      expect(
        screen.getByText("Dynamic Europe: Amsterdam, Prague, Berlin")
          .parentElement
      ).toHaveAttribute("aria-live", "off");
    }
  );
});

Solution

透過 action="toggle" 讓按鈕可以控制自動輪播,
且開啟跟關閉自動輪播時,會同步更新按鈕的文字。

function setup(interval?: number) {
  user.setup();
  render(
    <Carousel aria-label="Highlighted television shows" interval={interval}>
      <Carousel.Button action="toggle">
        {(auto_rotation) =>
          auto_rotation ? "pause auto-rotation" : "start auto-rotation"
        }
      </Carousel.Button>
      <Carousel.Button action="next">next slide</Carousel.Button>
      <Carousel.Button action="prev">previous slide</Carousel.Button>
      <Carousel.Items>
        <Carousel.Item>Dynamic Europe: Amsterdam, Prague, Berlin</Carousel.Item>
        <Carousel.Item>Travel to Southwest England and Paris</Carousel.Item>
        <Carousel.Item>Great Children's Programming on Public TV</Carousel.Item>
        <Carousel.Item>Foyle’s War Revisited</Carousel.Item>
        <Carousel.Item>Great Britain Vote: 7 pm Sat.</Carousel.Item>
        <Carousel.Item>Mid-American Gardener: Thursdays at 7 pm</Carousel.Item>
      </Carousel.Items>
    </Carousel>
  );
}

context 增加可以控制自動輪播的函式。

interface State {
  items_id: string;
  index: number;
  next: () => void;
  prev: () => void;
  pause: () => void;
  start: () => void;
  auto_rotation: boolean;
}

按鈕增加新的 action toggle 用於控制自動輪播開關。
同時提供 Render Props 讓用戶可以根據當前開關狀態控制顯示文字。

type ButtonAction = "next" | "prev" | "toggle";
type ButtonProps = PCP<
  "button",
  {
    action?: ButtonAction;
    children: ((auto_rotation: boolean) => ReactNode) | ReactNode;
  }
>;
function Button(props: ButtonProps) {
  const context = useCarouselContext(
    `<Carousel.Button /> cannot be rendered outside <Carousel />`
  );
  const onClick = () => {
    if (props.action === "next") return context.next();
    if (props.action === "prev") return context.prev();
    if (props.action === "toggle")
      return context.auto_rotation ? context.pause() : context.start();
  };

  const children = (() => {
    if (typeof props.children === "function") {
      return props.children(context.auto_rotation);
    }
    return props.children;
  })();

  return (
    <button
      type="button"
      aria-controls={context.items_id}
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
}

增加一個新的狀態 Pause 用於表示暫停狀態,
當暫停狀態結束,應當回復到前一狀態 (e.g. hover / focus 等等)。

enum CarouselState {
  None = 0b0000,
  Hover = 0b0001,
  Focus = 0b0010,
  Pause = 0b0100,
}
function stateReducer(
  state: CarouselState,
  action: "enter" | "leave" | "focus" | "blur" | "pause" | "start"
) {
  if (action === "enter") return state | CarouselState.Hover;
  if (action === "leave") return state ^ CarouselState.Hover;
  if (action === "focus") return state | CarouselState.Focus;
  if (action === "blur") return state ^ CarouselState.Focus;
  if (action === "pause") return state | CarouselState.Pause;
  if (action === "start") return state ^ CarouselState.Pause;
  return state;
}

透過 contain 判斷是否包含特定 bit。

const contain = (x: number, y: number) => (x & y) === x;

當前狀態包含 Pause 則直接關閉自動輪播。

export function Carousel(props: CarouselProps) {
  const id = useId();
  const [index, counterDispatch] = useReducer(counterReducer, 0);
  const next = () => counterDispatch("asc");
  const prev = () => counterDispatch("desc");

  const [state, stateDispatch] = useReducer(stateReducer, CarouselState.None);
  const enter = () => stateDispatch("enter");
  const leave = () => stateDispatch("leave");
  const focus = () => stateDispatch("focus");
  const blur = () => stateDispatch("blur");
  const pause = () => stateDispatch("pause");
  const start = () => stateDispatch("start");

  const auto_rotation = (() => {
    if (contain(CarouselState.Pause, state)) return false;

    return state === CarouselState.None && Boolean(props.interval);
  })();

  const context = {
    items_id: id + "items",
    index,
    next,
    prev,
    pause,
    start,
    auto_rotation,
  };

  useEffect(() => {
    if (!auto_rotation) return;
    const id = setInterval(next, props.interval);
    return () => clearInterval(id);
  }, [auto_rotation, props.interval, next]);

  return (
    <Context.Provider value={context}>
      <section
        aria-roledescription="carousel"
        onPointerEnter={enter}
        onPointerLeave={leave}
        onFocusCapture={focus}
        onBlurCapture={blur}
        {...props}
      />
    </Context.Provider>
  );
}

Spec: Keyboard Support

按鍵支援。

describe("keyboard support", () => {
  describe("tab", () => {
    it("moves focus through interactive elements in the carousel", async () => {
      setup(100);
      await user.keyboard("{Tab}");
      expect(screen.getByText("start auto-rotation")).toHaveFocus();
      await user.keyboard("{Tab}");
      expect(screen.getByText("next slide")).toHaveFocus();
      await user.keyboard("{Tab}");
      expect(screen.getByText("previous slide")).toHaveFocus();
    });
  });
  describe("enter", () => {
    it("display next slide in the carousel", async () => {
      setup();
      await user.keyboard("{Tab}");
      await user.keyboard("{Tab}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("2 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("3 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("4 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("5 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("6 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
    });
    it("display previous slide in the carousel", async () => {
      setup();
      await user.keyboard("{Tab}");
      await user.keyboard("{Tab}");
      await user.keyboard("{Tab}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("6 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("5 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("4 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("3 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("2 of 6");
      await user.keyboard("{Enter}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
    });
  });
  describe("space", () => {
    it("display next slide in the carousel", async () => {
      setup();
      await user.keyboard("{Tab}");
      await user.keyboard("{Tab}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("2 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("3 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("4 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("5 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("6 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
    });
    it("display previous slide in the carousel", async () => {
      setup();
      await user.keyboard("{Tab}");
      await user.keyboard("{Tab}");
      await user.keyboard("{Tab}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("6 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("5 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("4 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("3 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("2 of 6");
      await user.keyboard("{Space}");
      expect(screen.getByRole("group")).toHaveAccessibleName("1 of 6");
    });
  });
});

Solution

function Button(props: ButtonProps) {
  const context = useCarouselContext(
    `<Carousel.Button /> cannot be rendered outside <Carousel />`
  );
  const onClick = () => {
    if (props.action === "next") return context.next();
    if (props.action === "prev") return context.prev();
    if (props.action === "toggle")
      return context.auto_rotation ? context.pause() : context.start();
  };

  const children = (() => {
    if (typeof props.children === "function") {
      return props.children(context.auto_rotation);
    }
    return props.children;
  })();

  const ref = useRef<HTMLButtonElement>(null);
  useEffect(() => {
    const keydown = (event: KeyboardEvent) => {
      if (ref.current !== document.activeElement) return;

      if (event.key === "Space") {
        onClick();
      }
    };
    window.addEventListener("keydown", keydown);
    return () => window.removeEventListener("keydown", keydown);
  }, [onClick]);

  return (
    <button
      ref={ref}
      type="button"
      aria-controls={context.items_id}
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
}

上一篇
如何製作輪播 carousel 3【 carousel | 我不會寫 React Component 】
下一篇
如何製作警示 alert【 alert | 我不會寫 React Component 】
系列文
【 我不會寫 React Component 】30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言